[译]关于证书,我所知道的一切——客户端

原文:All I Know About Certificates -- Clients

终于,在上一篇文章中,我们讨论了证书颁发机构的责任,说明作为证书颁发机构并不简单,并且需要高昂的管理成本,解释了为什么签发证书会花费金钱!在这篇文章中,我们将关注这条链中的客户端。

作为客户端验证证书

对于客户端来说,验证证书也并不简单。 介绍 TLS 握手的文章经常提到“服务器返回一个证书,客户端进行验证”,但事实如我们所见,服务器返回多个证书! 这可以通过包捕获来确认: packet capture

你可以看到服务器一次返回三个证书(这大大增加了建立 TLS 的成本,接近 4K 的数据)。

建立信任链

为了信任证书,客户端必须最终将其与受信任的根 CA 进行比较。 一旦验证了根 CA,客户端就会信任由根签名的证书等等。 首先,客户端必须建立一个发行链来验证到根。每个上层都必须确认已验证的证书有效。 构建此链的基础来自证书中的签发者字段。每个证书都指定了谁颁发了该证书。 发行链 你可以看到 zoom.us 的证书签发者是 DigiCert TLS RSA SHA256 2020 CA1,而该证书的签发者是 DigiCert Global Root CA

什么构成有效?

  1. 证书不能过期。
  2. 发证机构的公钥必须能够验证签名。
  3. 发证机构必须为 CA:TRUE。
  4. ......

验证步骤

验证从 zoom.us 证书开始。如果有效,客户端会验证其签发者,并继续这个过程直到达到一个签发者为自身的证书,这表明它是一个根证书。服务器端发送的根证书不被信任;只有本地存储的根证书才被信任。如果本地存储的根证书可以验证中间证书,则一切都好。

伪代码验证过程

验证过程可以用下面的伪代码来表示:

def validate(cert):

  now = datetime.now()
  if now < cert.not_before or now > cert.not_after:
    return False, "Expired certificate"

  for issuer_cert in lookup_cert_by_name(cert.issuer):
    if now < issuer_cert.not_before or now > issuer_cert.not_after:
        continue

    if validate_signature(cert.data, cert.signature, issuer_cert.public_key):
       if cert.issuer is None:
          if is_trusted_root_ca(cert):
            return True, "Valid certificate"
          else:
            return False, "Uknown root CA"

        return validate(issuer_cert)

 return False, "No parent certificate"

打印证书链脚本

你可以使用下面的脚本打印网站证书链:

$ echo | openssl s_client -showcerts -connect zoom.us:443 -servername zoom.us 2> /dev/null | grep -A1 s:
 0 s:C = US, ST = California, L = San Jose, O = "Zoom Video Communications, Inc.", CN = *.zoom.us
   i:C = US, O = DigiCert Inc, CN = DigiCert TLS RSA SHA256 2020 CA1
--
 1 s:C = US, O = DigiCert Inc, CN = DigiCert TLS RSA SHA256 2020 CA1
   i:C = US, O = DigiCert Inc, OU = www.digicert.com, CN = DigiCert Global Root CA

使用 OpenSSL 进行手动验证

为了更好地理解证书验证过程,我们可以使用 openssl 手动验证证书。 以下是用于下载证书的脚本:

download_site_cert_chain () {
        openssl s_client -showcerts -verify 5 -connect $1:443 < /dev/null | awk '/BEGIN CERTIFICATE/,/END CERTIFICATE/{ if(/BEGIN CERTIFICATE/){a++}; out="cert"a".pem"; print >out}'
        for cert in *.pem
        do
                newname=$(openssl x509 -noout -subject -in $cert | sed -nE 's/.*CN ?= ?(.*)/\1/; s/[ ,.*]/_/g; s/__/_/g; s/_-_/-/; s/^_//g;p' | tr '[:upper:]' '[:lower:]').pem
                echo "${newname}"
                mv -v "${cert}" "${newname}"
        done
}

使用: download_site_cert_chain zoom.us

这会下载证书并重新命名。

$ ls
digicert_tls_rsa_sha256_2020_ca1.pem  zoom_us.pem

下面的命令可以用来验证下载的证书

$ openssl verify zoom_us.pem
C = US, ST = California, L = San Jose, O = "Zoom Video Communications, Inc.", CN = *.zoom.us
error 20 at 0 depth lookup: unable to get local issuer certificate
error zoom_us.pem: verification failed

验证失败是因为 zoom.us 的签发者不是 OpenSSL 认可的 CA,而是一个中间 CA。默认情况下,OpenSSL 不信任这个中间 CA,因此我们需要通知 OpenSSL 关于另一个证书——中间 CA 的证书。使用这个中间 CA 证书,OpenSSL 可以发现 digicert_tls_rsa_sha256_2020_ca1.pem 是由 DigiCert 颁发的。这是您可以解决它的方法:

$ openssl verify -untrusted digicert_tls_rsa_sha256_2020_ca1.pem zoom_us.pem
zoom_us.pem: OK

现在已经成功验证。 我们也可以检查 OpenSSL 是否正在本地读取证书颁发机构文件。 使用 strace,我们可以看到 OpenSSL 打开了哪些文件:

strace openssl verify -untrusted digicert_tls_rsa_sha256_2020_ca1.pem zoom_us.pem 2>&1 | grep open

strace 我们可以看到 OpenSSL 打开了我们提供的两个 .pem 证书文件。然而,不清楚哪个是根证书颁发机构。 实际上,最后一个文件 /usr/lib/ssl/certs/3513523f.0 是根证书颁发机构。 您可能会注意到上述过程似乎缺少一个步骤:客户端如何将服务器返回的证书映射到本地根 CA 文件? 答案在于中间证书颁发者。 OpenSSL 为每个根证书的主题创建一个重命名的散列值,并且创建符号链接(也许是为了更快地查找)。在搜索过程中,OpenSSL 使用中间证书颁发者的散列值(即根证书的主题)来查找本地证书文件。

$ ls -l /usr/lib/ssl/certs/3513523f.0
lrwxrwxrwx 1 root root 27 Aug  9  2022 /usr/lib/ssl/certs/3513523f.0 -> DigiCert_Global_Root_CA.pem

我们检查此证书的主题,它与中间证书的签发者相匹配:

$ openssl x509 -in /usr/lib/ssl/certs/DigiCert_Global_Root_CA.pem -noout --subject
subject=C = US, O = DigiCert Inc, OU = www.digicert.com, CN = DigiCert Global Root CA

我们可以手动计算散列值:

$ openssl x509 -in /usr/lib/ssl/certs/DigiCert_Global_Root_CA.pem -noout --subject_hash
3513523f

它匹配了 /usr/lib/ssl/certs/3513523f.0。那么 .0 是什么呢?这留给读者练习。

根 CA 交叉签名

早些时候,我们提到新 CA 在进入市场之前需要一个已经建立起来的 CA 来支持它。这在实践中是如何运作的? 我们使用了上面提到的脚本来获取维基百科的证书(由 Let's Encrypt 颁发,真的是一个伟大的组织!):

0 s:CN = wikipedia.com
   i:C = US, O = Let's Encrypt, CN = R3
--
 1 s:C = US, O = Let's Encrypt, CN = R3
   i:C = US, O = Internet Security Research Group, CN = ISRG Root X1
--
 2 s:C = US, O = Internet Security Research Group, CN = ISRG Root X1
   i:O = Digital Signature Trust Co., CN = DST Root CA X3

这里有三个证书。当客户端开始验证wikipedia.com时:

  1. 验证由 R3 颁发的 wikipedia.com 证书。如果成功:
  2. 验证由 ISRG Root X1 颁发的 R3 证书。如果客户端本地存储了 ISRG Root X1(即信任),则使用本地验证,验证成功完成。否则:
  3. 验证 DST Root CA X3 证书。如果客户端信任 DST Root CA X3,则验证成功完成。

这就是交叉签名验证的原则。

2021 年 9 月,发生了一件值得注意的事情: DST Root CA X3根证书 在 2021 年 9 月 30 日到期。这意味着如果客户端不信任 ISRG Root X1根证书,则不能信任在那之后由 Let's Encrypt 颁发的任何证书。到那时,Let's Encrypt 已经获得了相当大的信任,并且主流浏览器和操作系统已经直接信任它了。因此不太可能出现问题。

然而,一些长期未更新的 Ubuntu 16.04 服务器(只有更新才能提供新的证书颁发机构证书)不信任 ISRG Root X1根证书。因此,在 2021 年 9 月 30 日,许多服务器在访问其他网站和 API 时遇到错误。这证明了客户端不良的根证书维护可能会导致问题。

中级 CA 交叉签名

现在我们来讨论一个真正棘手的话题。前面提到的根 CA 交叉签名是一种方法,也是最常见的一种。只有一条链:证书 > R3> X1> DST,客户端可以这样验证它。

然而,中间 CA 也可以进行交叉签名。

以下是需要再次强调的一些要点(我之前对此感到困惑):

  • 证书只能有一个签发者,因为证书中的签发者是一个固定的字段,而不是一个列表。
  • 签名的本质只是使用私钥对哈希值进行加密。
  • 中间证书不被客户端直接信任;客户端只信任根 CA。

有了这些考虑,让我们来看看 Let’s Encrypt 之前用于 R3 中间证书的交叉签名方法(这是一种旧的方法,不再使用;不要与上面实际捕获的示例混淆。如今,中间证书通常不进行交叉签名;X1 根证书由 DST Root 直接签署。下面是一个简化的历史示例,忽略其他证书链): 交叉签名 等一下,证书不应该是只有一个签发者吗?为什么图表上显示 R3 有两个签发者? 没错;许多图表显示中间 CA 由多个根 CA 交叉签名,但实际上并非如此。事实上,有两个证书!R3 从 DST Root CA X3 和 ISRG Root CA X1 分别获得了一个证书。在现实中,证书链应该是这样的: 证书链 R3 获得两个已签名证书!所以,当 example.com 寻找 R3 的签名时,R3 应该使用哪个? 答案是:任何一个

让我们回忆一下之前关于签名原则的讨论,本质上是在附加一个加密的哈希值。当 R3 使用相同的证书从两个不同的根进行签名时,实际上生成的证书具有相同公钥和私钥。因此,当 R3 为其他网站签名时,使用任何已签名证书中的私钥都会产生相同的哈希值。 核心思想是证书签名实质上涉及使用私钥对哈希值进行加密。

这意味着一个中间 CA 可以被多个 ICAs 签名,从而产生多个证书。这些中间 CA 证书必须返回给客户端,因为客户端需要它们来识别发行根 CA 并构建信任链。

例如,当一个网站获得 R3 签名的证书,并与访客建立 TLS 连接时,它需要返回:

  • example.com 网站证书。
  • 由 DST Root CA X3 签发的 R3 证书。
  • 由 ISRG Root X1 签发的 R3 证书。

这样,客户无论是否信任 DST Root CA X3 或 ISRG Root X1 都可以获得信任。

一个例子发生在当一个网站从 Let's Encrypt 获得证书,但只返回:

  • example.com 网站证书。
  • 由 DST Root CA X3 签发的 R3 证书。

它缺少 ISRG Root X1 根证书颁发机构签发的 R3 证书,因此在 2021 年 9 月 DST Root CA X3 根证书颁发机构过期时,网站不受信任。

我实际上没有验证这部分,因为我的脚本下载了网站证书链,但没有使用中间证书交叉签名;他们都使用根 CA 交叉签名。据说即使缺少中间证书,客户端也可以找到信任链并成功验证它。我不敢确定这一点,因为它可能取决于客户端的行为。对于客户端来说,验证并不是一个问题,因为这些证书都是完全有效的;只是无法完成信任链来完成验证。

为什么没有一个案例涉及中间证书交叉签名? 我认为这是因为如果根 CA 用于验证旧的 CA,客户端的验证过程仍然是单个线性链。 与中间证书进行交叉签名会复杂化验证过程。 然而,并没有确凿的证据证明这一点。 如果读者知道更多,请随时分享。

客户端修改本地 CA

通常,客户端证书由软件或操作系统管理,但证书只是文件,用户可以自行管理这些文件。这有什么问题?

在中间人攻击(MITM)的例子中,如果没有网站证书,则客户端无法信任攻击者。 中间人无法查看或篡改通信内容,并且如果冒充目标网站,则会暴露没有有效证书的事实。

假设恶意根证书颁发机构向黑客签发了谷歌的证书,而客户端信任该根证书颁发机构。然后,黑客可以伪造网站,而客户端浏览器会信任它。

历史上曾发生过这样的事情。 证书颁发机构——VeriSign Class 3 Public Primary Certification Authority – G5 错误地签发了谷歌的证书,导致许多操作系统撤销了对根证书颁发机构的信任。

在阿里巴巴(作者曾在此工作),我们有一个名为“AliLang”的办公软件。如果不能安装其证书到个人手机上,很多功能都无法使用。安装它的证书意味着信任它发出的任何证书,允许它作为中间人来查看用户手机上的所有流量,包括聊天记录、HTTPS 登录用户名和密码(尽管该公司声称不收集用户的隐私)。

通过在你的电脑上安装证书,MITMProxy 可以拦截 HTTPS 流量,模拟一个 MITM 攻击。当你访问 example.com 时,MITMProxy 接收到请求,并返回自己的证书代替 example.com 的证书。由于你信任 MITMProxy 的证书,客户端认为自己正在连接真正的 example.com 并通过这个连接发送内容。代理可以看到明文请求并将其转发到实际目标,从而实现对 HTTPS 包的捕获。

一些应用程序(可能为了隐藏数据收集)拒绝信任所有证书颁发机构,只信任它们自己的证书。

客户端证书固定

这个原则很简单。一些程序不使用系统维护的证书,而是维护一个受信任的 CA 列表,比如 Chrome。

当客户端进一步将其信任列表缩小到仅包含其证书(或其 CA)时,这被称为 客户端证书固定。例如,抖音应用使用了它。即使我们使用 MITM Proxy,我们也无法拦截它的流量,因为它不信任由 MITM 发出的证书。它只信任硬编码的证书(除非您找到这些证书存储的位置并修改它们以绕过此限制)。

在接下来的文章中,我们将更多地讨论网站。

参考:https://www.kawabangga.com/posts/5330